Интеграционные тесты на дедлоки и одновременные запросы
Я в последнее время всё больше люблю писать интеграционные (API) тесты — запускаю половину приложения, но не привязан к UI. Это золотая середина между очень медленными end-to-end тестами и очень быстрыми unit-тестами. Рассмотрим особый случай таких тестов, которые используют заготовленные данные под каждый тест.
Такие тесты приходится создавать, когда проект становится таких масштабов, что тесты с одной БД начинают конфликтовать между собой и становятся нестабильными. То список где-то постоянно растёт, то ID у ресурса требуется фиксированный а у нас autoincrement, то данные хочется удалить.
Это особенно ярко видно в e2e тестах, где приходится управлять всем жизненным циклом данных что-бы тесты оставались рабочими. Управление всем циклом из создания-операции-удаления, вынуждает тесты делать зависимыми друг от друга, а значит становится невозможно запустить тест сам по себе.
Пример
Вот как выглядит моё решение этой проблемы..
use kurapov\tests\database\IsolatedDataIntegrationTestBase;
class UserIsolatedDataTest extends IsolatedDataIntegrationTestBase {
/**
* @test
*/
function postRemove_UserByManager() {
//$this->db->execute(file_get_contents(dirname(realpath(__FILE__)) . '/' . __CLASS__ . '/' . __FUNCTION__ . '.sql'));
$this->db->execute(
"INSERT INTO `user` (`id`, `email`, `password`) VALUES (1,'manager@kurapov.ee','553ae7da92f5505a92bbb8c9d47be76ab9f65bc2');
INSERT INTO `user` (`id`, `email`, `password`) VALUES (2,'user@kurapov.ee','f4542db9ba30f7958ae42c113dd87ad21fb2eddb');"
);
$this->loginAs('manager@kurapov.ee');
$result = $this->curlPOST($this->baseURL . 'User/remove', ['id' => 2]);
$this->assertNotContains('error', $result);
$this->assertEquals("{'status':'ok'}", $result, $result);
}
}
В данном случае при запуске теста, схема БД уже существует — она изолирована и чиста. Я лишь добавляю данные и делаю curl запрос. Я не проверяю итоговое состояние в БД в данном случае. Если SQL очень длинный, я могу вынести его в отдельный файл.
Поскольку этот тест одновременно занимается и сетевыми запросами (curlPOST функция) и подготовкой БД, то сам класс наследует написанный мною IntegrationTestBase и IsolatedDataIntegrationTestBase соответственно. Если бы я напрямую работал с функцией, без сетевых запросов, возможно я мог бы использовать DBUnit.
Пишу я в сыром SQL для mysql, абстрагированием (скажем с doctrine) и переключением на in-memory БД я не занимаюсь. Вместо этого, процесс подготовки у меня такой:
- До запусков всех тестов прогоняются все миграции что-бы иметь up-to-date схему
- В фазе setUp теста - удаляем тестовую БД
- Копируем всю схему базы проекта в новую тестовую БД, без данных
- Для конкретного теста запускаем SQL для добавления специфичных данных (Пользователи, данные, связки)
- Запускаем сам тест, который делает сетевой запрос
- В сетевом запросе - указываем дополнительный параметр, который в конфигурации переключает наш проект на тестовую БД (только для локального запуска)
- Если тест падает - у нас в БД остаётся состояние тестовой БД, можно посмотреть
А так выглядит файлик подготавливающий тестовую БД..
namespace kurapov\tests\database;
use IntegrationTestBase;
use PDO;
class IsolatedDataIntegrationTestBase extends IntegrationTestBase {
const DEV_DBNAME = "myproject";
const TEST_DBNAME = "myproject-test";
public function setUp() {
$this->db = new \kurapov\Database(new \PDO(
'mysql:host=127.0.0.1;dbname=' . self::DEV_DBNAME . ";charset=utf8",
'root','',
[
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
\PDO::ATTR_PERSISTENT => true,
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
]
));
$this->db->execute("DROP DATABASE IF EXISTS `" . self::TEST_DBNAME . "`;");
$this->duplicateDB();
$this->db = new \kurapov\Database(new \PDO(
'mysql:host=127.0.0.1;dbname=' . self::TEST_DBNAME . ";charset=utf8",
'root','',
[
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
\PDO::ATTR_PERSISTENT => true,
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
]
));
}
public function tearDown() {
// $this->db->execute("DROP DATABASE `" . self::TEST_DBNAME . "`;");
}
protected function curlPost($url, $data, $useCookie = true) {
$data['isolated_db'] = self::TEST_DBNAME;
return parent::curlPost($url, $data, $useCookie);
}
protected function curlGET($url, $useCookie = true) {
return parent::curlGET($url . '&isolated_db=' . self::TEST_DBNAME, $useCookie);
}
private function duplicateDB() {
$tables = $this->db->execute("SHOW TABLES;");
$this->db->execute("CREATE DATABASE `" . self::TEST_DBNAME . "`;");
foreach ($tables as $table) {
$tableName = $table['Tables_in_' . self::DEV_DBNAME];
$this->db->execute("CREATE TABLE `" . self::TEST_DBNAME . "`.`$tableName` LIKE `" . self::DEV_DBNAME . "`.`$tableName`;");
}
}
public function copyTable($table) {
$this->db->execute(
"INSERT INTO `" . self::TEST_DBNAME . "`.$table
SELECT * FROM `" . self::TEST_DBNAME . "`.$table"
);
}
}
Итого
Я не форсирую использование таких тестов для всех случаев, а только там где мне кажется это необходимым. Большинство интеграционных тестов по-прежнему бегает на одной БД.
Достоинства
- Тесты становятся стабильней, т.к. меньше зависимости друг от друга и данные не затираются/не добавляются
- Изолированные тесты быстрей выполняются, чем цепочка запросов/тестов
- Написание SQL для тестов начинает влиять на проектирование БД
- В случае падения теста, можно посмотреть состояние тестовой БД именно в этом контексте
- Потенциально можно расширить применение на e2e тесты либо распараллелить запуск используя отдельную БД на каждый тест или поток
Недостатки
- Надо подготавливать для каждого теста свои данные в SQL это неприятно
- Надо поддерживать этот SQL если вы измените схему и она затрагивает тест